//
//  CommandLine.swift
//  SwiftFormat
//
//  Created by Nick Lockwood on 10/01/2017.
//  Copyright 2017 Nick Lockwood
//
//  Distributed under the permissive MIT license
//  Get the latest version from here:
//
//  https://github.com/nicklockwood/SwiftFormat
//
//  Permission is hereby granted, free of charge, to any person obtaining a copy
//  of this software and associated documentation files (the "Software"), to deal
//  in the Software without restriction, including without limitation the rights
//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
//  copies of the Software, and to permit persons to whom the Software is
//  furnished to do so, subject to the following conditions:
//
//  The above copyright notice and this permission notice shall be included in all
//  copies or substantial portions of the Software.
//
//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
//  SOFTWARE.
//

import Foundation

/// Public interface for the SwiftFormat command-line functions
public enum CLI {}

public extension CLI {
    /// Output type for printed content
    enum OutputType {
        case info
        case success
        case error
        case warning
        case content
        case raw
    }

    /// Output handler - override this to intercept output from the CLI
    static var print: (String, OutputType) -> Void = { _, _ in
        fatalError("No print hook set.")
    }

    /// Input handler - override this to inject input into the CLI
    /// Injected lines should include the terminating newline character
    static var readLine: () -> String? = {
        Swift.readLine(strippingNewline: false)
    }

    /// Run the CLI with the specified input arguments
    static func run(in directory: String, with args: [String] = CommandLine.arguments) -> ExitCode {
        processArguments(args, environment: ProcessInfo.processInfo.environment, in: directory)
    }

    /// Run the CLI with the specified input string (this will be parsed into multiple arguments)
    static func run(in directory: String, with argumentString: String) -> ExitCode {
        run(in: directory, with: parseArguments(argumentString))
    }
}

private var quietMode = false
private func print(_ message: String, as type: CLI.OutputType = .info) {
    if !quietMode || [.raw, .content, .error].contains(type) {
        CLI.print(message, type)
    }
}

/// Print warnings and return true if any was an actual error
private func printWarnings(_ errors: [Error]) -> Bool {
    var containsError = false
    for error in errors {
        var message = "\(error)"
        if !".?!".contains(message.last ?? " ") {
            message += "."
        }
        let isError: Bool
        switch error as? FormatError {
        case let .writing(string)?:
            isError = !string.contains(" cache ")
        case .parsing?, .reading?, .options?:
            isError = true
        case nil:
            isError = true
            message = error.localizedDescription
        }
        if isError {
            containsError = true
            print("error: \(message)", as: .error)
        } else {
            print("warning: \(message)", as: .warning)
        }
    }
    return containsError
}

/// Represents the exit codes to the command line. See `man sysexits` for more information.
public enum ExitCode: Int32 {
    case ok = 0 // EX_OK
    case lintFailure = 1
    case error = 70 // EX_SOFTWARE
}

func printOptions(_ options: [OptionDescriptor] = Descriptors.formatting, as type: CLI.OutputType) {
    print("")
    print(options.compactMap {
        guard !$0.isDeprecated else { return nil }
        var result = "--\($0.argumentName)"

        let maxNameLengthForSingleLineFormatting = 16
        let optionNameColumnWidth = maxNameLengthForSingleLineFormatting + 3

        if $0.argumentName.count <= maxNameLengthForSingleLineFormatting {
            for _ in 0 ..< optionNameColumnWidth - result.count {
                result += " "
            }
            return result + stripMarkdown($0.help)
        } else {
            result += "\n"
            for _ in 0 ..< optionNameColumnWidth {
                result += " "
            }
            return result + stripMarkdown($0.help)
        }
    }.sorted().joined(separator: "\n"), as: type)
    print("")
}

func printRuleInfo(for name: String, as type: CLI.OutputType) throws {
    guard let rule = FormatRules.byName[name] else {
        if name.isEmpty {
            throw FormatError.options("--rule-info command expects a rule name")
        }
        throw FormatError.options("'\(name)' rule does not exist")
    }
    print("")
    print(name, as: type)
    print("", as: type)
    print(stripMarkdown(rule.help), as: type)
    if let message = rule.deprecationMessage {
        print("", as: type)
        print("Note: \(rule.name) rule is deprecated. \(message)")
        print("")
        return
    }
    if !rule.options.isEmpty {
        print("\nOptions:\n", as: type)
        print(rule.options.compactMap {
            guard let descriptor = Descriptors.byName[$0], !descriptor.isDeprecated else {
                return nil
            }
            var result = "--\(descriptor.argumentName)"
            for _ in 0 ..< 19 - result.count {
                result += " "
            }
            return result + stripMarkdown(descriptor.help)
        }.sorted().joined(separator: "\n"), as: type)
    }
    if var examples = rule.examples {
        examples = examples
            .replacingOccurrences(of: "```diff\n", with: "")
            .replacingOccurrences(of: "```\n", with: "")
        if examples.hasSuffix("```") {
            examples = String(examples.dropLast(3))
        }
        print("\nExamples:\n", as: type)
        print(examples, as: type)
    }
    print("")
}

func printHelp(as type: CLI.OutputType) {
    print("")
    print("""
    SwiftFormat, version \(version)
    Copyright (c) 2016 Nick Lockwood

    --help             Print this help page
    --version          Print the currently installed swiftformat version

    SwiftFormat can operate on files & directories, or directly on input from stdin.

    Usage: swiftformat [<file> <file> ...] [--infer-options] [--output path] [...]

    <file> <file> ...  Swift files or directories to be processed, or "stdin"

    --filelist         Path to a file with names of files to process, one per line
    --stdin-path       Path to stdin source file (used for generating header)
    --script-input     Read Xcode SCRIPT_INPUT_FILE* environment variables as files
    --config           Path(s) to configuration file(s) containing rules and options
    --base-config      Like --config, but local .swiftformat files aren't ignored
    --infer-options    Instead of formatting input, use it to infer format options
    --output           Output path for formatted file(s) (defaults to input path)
    --exclude          Comma-delimited list of ignored paths (supports glob syntax)
    --unexclude        Paths to not exclude, even if excluded elsewhere in config
    --filter           Filters a config file to only apply to paths matching a glob.
    --symlinks         How symlinks are handled: "follow" or "ignore" (default)
    --line-range       Range of lines to process within the input file (first, last)
    --fragment         \(stripMarkdown(Descriptors.fragment.help))
    --conflict-markers \(stripMarkdown(Descriptors.ignoreConflictMarkers.help))
    --swift-version    \(stripMarkdown(Descriptors.swiftVersion.help))
    --language-mode    \(stripMarkdown(Descriptors.languageMode.help))
    --unknown-rules    How unknown rules are handled: "error" (default) or "ignore"
    --min-version      The minimum SwiftFormat version to be used for these files
    --cache            Path to cache file, or "clear" or "ignore" the default cache
    --dry-run          Run in "dry" mode (without actually changing any files)
    --lint             Return an error for unformatted input, and list violations
    --report           Path to a file where --lint output should be written
    --reporter         Report format: \(Reporters.help)
    --lenient          Suppress errors for unformatted code in --lint mode
    --strict           Emit errors for unformatted code when formatting
    --verbose          Display detailed formatting output and warnings/errors
    --quiet            Disables non-critical output messages and warnings
    --output-tokens    Outputs an array of tokens instead of text when using stdin
    --markdown-files   \(stripMarkdown(Descriptors.markdownFiles.help))

    SwiftFormat has a number of rules that can be enabled or disabled. By default
    most rules are enabled. Use --rules to display all enabled/disabled rules.

    --rules            The list of rules to apply. Pass nothing to print rules list
    --disable          Comma-delimited list of format rules to be disabled, or "all"
    --enable           Comma-delimited list of rules to be enabled, or "all"
    --lint-only        A list of rules to be enabled only when using --lint mode

    SwiftFormat's rules can be configured using options. A given option may affect
    multiple rules. Options have no effect if the related rules have been disabled.

    --rule-info        Display options for a given rule or rules (comma-delimited)
    --options          Prints a list of all formatting options and their usage
    """, as: type)
    print("")
}

func timeEvent(block: () throws -> Void) rethrows -> TimeInterval {
    #if os(macOS)
        let start = CFAbsoluteTimeGetCurrent()
        try block()
        return CFAbsoluteTimeGetCurrent() - start
    #else
        let start = Date.timeIntervalSinceReferenceDate
        try block()
        return Date.timeIntervalSinceReferenceDate - start
    #endif
}

private func formatTime(_ time: TimeInterval) -> String {
    let time = round(time * 100) / 100 // round to nearest 10ms
    return String(format: "%gs", time)
}

private func serializeOptions(_ options: Options, to outputURL: URL?) throws {
    if let outputURL {
        let file = serialize(options: options) + "\n"
        do {
            try file.write(to: outputURL, atomically: true, encoding: .utf8)
        } catch {
            throw FormatError.writing("Failed to write options to \(outputURL.path)")
        }
    } else {
        print(serialize(options: options, excludingDefaults: true, separator: " "), as: .content)
        print("")
    }
}

private func readConfigArg(
    _ name: String,
    with args: inout [String: String],
    filterOptions: inout [Glob: [String: String]],
    in directory: String
) throws -> URL? {
    guard let configPath = args[name] else {
        return nil
    }
    if configPath.isEmpty {
        throw FormatError.options("--\(name) argument expects a value")
    }

    let (url, configs) = try processConfigFile(at: configPath, for: name, in: directory)

    var config = [String: String]()

    for configArgs in configs {
        // If the config file has a `--filter` option, store it separately under that glob.
        if let filterGlob = configArgs["filter"] {
            for glob in expandGlobs(filterGlob, in: "/") {
                filterOptions[glob] = configArgs
            }
        } else {
            config = try mergeArguments(configArgs, into: config)
        }
    }

    args = try mergeArguments(args, into: config)
    return url
}

private func processConfigFile(at path: String, for argumentName: String, in directory: String) throws -> (URL, [[String: String]]) {
    let url = try parsePath(path, for: "--\(argumentName)", in: directory)

    if !FileManager.default.fileExists(atPath: url.path) {
        throw FormatError.reading("Specified config file does not exist: \(url.path)")
    }

    let data: Data
    do {
        data = try Data(contentsOf: url)
    } catch {
        throw FormatError.reading("Failed to read config file at \(url.path), \(error)")
    }

    var configs = try parseConfigFile(data)

    // Ensure exclude paths in config file are treated as relative to the file itself
    let configDirectory = url.deletingLastPathComponent().path

    configs = configs.map { config in
        var config = config
        if let exclude = config["exclude"] {
            let excluded = expandGlobs(exclude, in: configDirectory)
            if excluded.isEmpty {
                print("warning: --exclude value '\(exclude)' did not match any files in \(configDirectory).", as: .warning)
                config["exclude"] = nil
            } else {
                config["exclude"] = excluded.map(\.description).sorted().joined(separator: ",")
            }
        }
        if let unexclude = config["unexclude"] {
            let unexcluded = expandGlobs(unexclude, in: configDirectory)
            if unexcluded.isEmpty {
                print("warning: --unexclude value '\(unexclude)' did not match any files in \(configDirectory).", as: .warning)
                config["unexclude"] = nil
            } else {
                config["unexclude"] = unexcluded.map(\.description).sorted().joined(separator: ",")
            }
        }
        return config
    }

    return (url, configs)
}

private func readMultipleConfigArgs(
    _ name: String,
    with args: inout [String: String],
    filterOptions: inout [Glob: [String: String]],
    in directory: String
) throws -> [URL] {
    guard let configPaths = args[name] else {
        return []
    }

    if configPaths.isEmpty {
        throw FormatError.options("--\(name) argument expects a value")
    }

    // Split comma-separated config paths
    let paths = parseCommaDelimitedList(configPaths)
    var configURLs: [URL] = []
    var mergedConfig: [String: String] = [:]

    // Process each config file in order (first as base, subsequent override)
    for (index, path) in paths.enumerated() {
        let (url, configs) = try processConfigFile(at: path, for: name, in: directory)
        for config in configs {
            // For first config file, use it as base; for subsequent files, merge them in.
            // If the config file has a `--filter` option, store it separately under that glob.
            if let filterGlob = config["filter"] {
                for glob in expandGlobs(filterGlob, in: "/") {
                    filterOptions[glob] = config
                }
            } else if index == 0 {
                mergedConfig = config
            } else {
                mergedConfig = try mergeArguments(config, into: mergedConfig)
            }
        }

        configURLs.append(url)
    }

    // Merge final config into args
    args = try mergeArguments(args, into: mergedConfig)
    return configURLs
}

typealias OutputFlags = (
    filesWritten: Int,
    filesChecked: Int,
    filesSkipped: Int,
    filesFailed: Int
)

func processArguments(_ args: [String], environment: [String: String] = [:], in directory: String) -> ExitCode {
    var errors = [Error]()
    var verbose = false
    var lenient = false
    var strict = false

    quietMode = false
    defer {
        // Reset quiet mode on exit to prevent side-effects between unit tests
        quietMode = false
    }

    do {
        // Get arguments
        var args = try preprocessArguments(args, commandLineArguments)

        // Quiet mode
        quietMode = (args["quiet"] != nil)

        // Verbose
        verbose = (args["verbose"] != nil)

        // Lenient
        lenient = (args["lenient"] != nil)

        // Strict
        strict = (args["strict"] != nil)

        // Lint
        let lint = (args["lint"] != nil)

        // Dry run
        let dryrun = lint || (args["dry-run"] != nil)

        // Whether or not to output tokens instead of source code
        let printTokens = args["output-tokens"] != nil

        // Warnings
        for warning in warningsForArguments(args) {
            print("warning: \(warning)", as: .warning)
        }

        // add args to environment
        let environment = environment.merging(args) { lhs, _ in lhs }

        // Report URL
        let reportURL: URL? = try args["report"].map { arg in
            guard !arg.isEmpty else {
                throw FormatError.options("--report argument expects a path")
            }
            return try parsePath(arg, for: "--output", in: directory)
        }

        // Reporter
        let reporter: Reporter = try args["reporter"].flatMap { identifier in
            guard let reporter = Reporters.reporter(
                named: identifier,
                environment: environment
            ) else {
                throw FormatError.invalidOption(
                    identifier,
                    for: "reporter",
                    // swiftformat:disable:next --preferKeyPath
                    with: Reporters.all.map { $0.name }
                )
            }
            return reporter
        } ?? reportURL.flatMap {
            Reporters.reporter(for: $0, environment: environment)
        } ?? DefaultReporter(environment: environment)

        // Throw if default reporter is used with explicit url
        guard reportURL == nil || !(reporter is DefaultReporter) else {
            throw FormatError.options("--report requires --reporter to be specified")
        }

        // Show help
        if args["help"] != nil {
            printHelp(as: .content)
            return .ok
        }

        // Show version
        if args["version"] != nil {
            print(version, as: .content)
            return .ok
        }

        // Show options
        if args["options"] != nil {
            printOptions(as: .content)
            return .ok
        }

        // Show rule info
        if let names = try args["rule-info"].map({ try parseRules($0, ignoreUnknown: false) }) {
            let names = names.isEmpty ? allRules.sorted() : names.sorted()
            for name in names {
                try printRuleInfo(for: name, as: .content)
            }
            return .ok
        }

        // Display rules (must be checked before merging config)
        let showRules: Bool = args["rules"].map {
            if $0 == "" {
                args["rules"] = nil
                return true
            }
            return false
        } ?? false

        // Config files (support multiple)
        var filterOptions = [Glob: [String: String]]()
        let configURLs = try readMultipleConfigArgs("config", with: &args, filterOptions: &filterOptions, in: directory)

        // FormatOption overrides
        var overrides = [String: String]()
        for key in formattingArguments + rulesArguments {
            overrides[key] = args[key]
        }

        // Base config
        _ = try readConfigArg("base-config", with: &args, filterOptions: &filterOptions, in: directory)

        // Options
        var options = try Options(args, filterOptions: filterOptions, in: directory)
        options.configURLs = configURLs.isEmpty ? nil : configURLs

        // Show rules
        if showRules {
            print("")
            let rules = options.rules ?? defaultRules
            for name in Array(allRules).sorted() {
                let annotation: String
                if rules.contains(name) {
                    annotation = ""
                } else if FormatRules.byName[name]?.isDeprecated == true {
                    annotation = " (deprecated)"
                } else {
                    annotation = " (disabled)"
                }
                print(" \(name)\(annotation)", as: .content)
            }
            print("")
            return .ok
        }

        // Input path(s)
        var inputURLs = [URL]()
        if let fileListPath = args["filelist"] {
            let fileListURL = try parsePath(fileListPath, for: "filelist", in: directory)
            if !FileManager.default.fileExists(atPath: fileListURL.path) {
                throw FormatError.reading("File not found at \(fileListURL.path)")
            }
            guard let source = try? String(contentsOf: fileListURL) else {
                throw FormatError.options("Failed to read file list at \(fileListPath)")
            }
            inputURLs += try parseFileList(source, in: fileListURL.deletingLastPathComponent().path)
        }
        var useStdin = false
        while let inputPath = args[String(inputURLs.count + 1)] {
            if inputPath.lowercased() == "stdin" {
                useStdin = true
                inputURLs.append(URL(string: "stdin")!)
            } else {
                inputURLs += try parsePaths(inputPath, in: directory)
            }
        }
        if useStdin {
            if inputURLs.count > 1 {
                if args["filelist"] != nil {
                    throw FormatError.options("--filelist option cannot be combined with stdin input")
                }
                throw FormatError.options("Cannot combine stdin with other file inputs")
            }
            inputURLs = []
        }
        if let stdinPath = args["stdin-path"] {
            if !useStdin {
                print("warning: --stdin-path option only applies when using stdin", as: .warning)
            }
            let stdinURL = try parsePath(stdinPath, for: "stdin-path", in: directory)
            let resourceValues = try getResourceValues(
                for: stdinURL.standardizedFileURL,
                keys: [.creationDateKey, .pathKey]
            )
            var formatOptions = options.formatOptions ?? .default

            formatOptions.fileInfo = FileInfo(
                filePath: resourceValues.path,
                creationDate: resourceValues.creationDate
            )
            options.formatOptions = formatOptions
        }
        if args["script-input"] != nil {
            inputURLs += try parseScriptInput(from: environment)
        }

        // Treat values for arguments that do not take a value as input paths
        func addInputPaths(for argName: String) throws {
            guard let arg = args[argName], !arg.isEmpty else {
                return
            }
            guard inputURLs.isEmpty || "/.,".contains(where: { arg.contains($0) }) else {
                throw FormatError.options("--\(argName) argument does not expect a value")
            }
            if arg.lowercased() == "stdin" {
                useStdin = true
            } else {
                inputURLs += try parsePaths(arg, in: directory)
            }
        }
        try addInputPaths(for: "quiet")
        try addInputPaths(for: "verbose")
        try addInputPaths(for: "lenient")
        try addInputPaths(for: "strict")
        try addInputPaths(for: "dry-run")
        try addInputPaths(for: "lint")
        try addInputPaths(for: "infer-options")

        // Output path
        var useStdout = false
        let outputURL = try args["output"].flatMap { arg -> URL? in
            if arg == "" {
                throw FormatError.options("--output argument expects a value")
            } else if inputURLs.count > 1 {
                throw FormatError.options("--output argument is only valid for a single input file or directory")
            } else if arg.lowercased() == "stdout" {
                useStdout = true
                return URL(string: "stdout")
            }
            if args["lint"] != nil {
                print("warning: --output argument is unused when running in --lint mode", as: .warning)
            }
            return try parsePath(arg, for: "--output", in: directory)
        }

        guard !useStdout || (reporter is DefaultReporter || reportURL != nil) else {
            throw FormatError.options("--report file must be specified when --output is stdout")
        }

        // Source range
        let lineRange = try args["line-range"].flatMap { arg -> ClosedRange<Int>? in
            if arg == "" {
                throw FormatError.options("--line-range argument expects a value")
            } else if inputURLs.count > 1 {
                throw FormatError.options("--line-range argument is only valid for a single input file")
            }
            let parts = arg.components(separatedBy: ",").map {
                $0.trimmingCharacters(in: .whitespacesAndNewlines)
            }
            guard (1 ... 2).contains(parts.count),
                  let start = parts.first.flatMap(Int.init),
                  let end = parts.last.flatMap(Int.init)
            else {
                throw FormatError.options("Unsupported --line-range value '\(arg)'")
            }
            return start ... end
        }

        // Infer options
        if args["infer-options"] != nil {
            guard configURLs.isEmpty else {
                throw FormatError.options("--infer-options option can't be used along with a config file")
            }
            guard args["range"] == nil else {
                throw FormatError.options("--infer-options option can't be applied to a line range")
            }
            if !inputURLs.isEmpty {
                print("Inferring swiftformat options from source file(s)...", as: .info)
                var filesParsed = 0, formatOptions = FormatOptions.default, errors = [Error]()
                let fileOptions = options.fileOptions ?? .default
                let time = formatTime(timeEvent {
                    (filesParsed, formatOptions, errors) = inferOptions(from: inputURLs, options: fileOptions)
                })
                if printWarnings(errors) || filesParsed == 0 {
                    throw FormatError.parsing("Failed to to infer options")
                }
                var filesChecked = filesParsed
                for case let error as FormatError in errors {
                    switch error {
                    case .parsing, .reading:
                        filesChecked += 1
                    case .writing:
                        assertionFailure()
                    case .options:
                        break
                    }
                }
                print("Options inferred from \(filesParsed)/\(filesChecked) files in \(time).", as: .info)
                print("")
                var options = options
                options.formatOptions = formatOptions
                try serializeOptions(options, to: outputURL)
                return .ok
            }
        }

        // Cache path
        var cacheURL: URL?
        let defaultCacheFileName = "swiftformat.cache"
        let manager = FileManager.default
        func setDefaultCacheURL() {
            let cacheDirectory = { () -> URL in
                #if os(macOS)
                    if let cachePath = NSSearchPathForDirectoriesInDomains(
                        .cachesDirectory, .userDomainMask, true
                    ).first {
                        return URL(fileURLWithPath: cachePath)
                    }
                #endif
                if #available(macOS 10.12, *) {
                    return FileManager.default.temporaryDirectory
                } else {
                    return URL(fileURLWithPath: "/var/tmp/")
                }
            }().appendingPathComponent("com.charcoaldesign.swiftformat")
            do {
                try manager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true, attributes: nil)
                cacheURL = cacheDirectory.appendingPathComponent(defaultCacheFileName)
            } catch {
                errors.append(FormatError.writing("Failed to create cache directory at \(cacheDirectory.path)"))
            }
        }
        if let cache = args["cache"] {
            switch cache {
            case "":
                throw FormatError.options("--cache option expects a value")
            case "ignore":
                break
            case "clear":
                setDefaultCacheURL()
                if let cacheURL, manager.fileExists(atPath: cacheURL.path) {
                    do {
                        try manager.removeItem(at: cacheURL)
                    } catch {
                        errors.append(FormatError.writing("Failed to delete cache file at \(cacheURL.path)"))
                    }
                }
            default:
                cacheURL = try parsePath(cache, for: "--cache", in: directory)
                guard cacheURL != nil else {
                    throw FormatError.options("Invalid --cache value '\(cache)'")
                }
                var isDirectory: ObjCBool = false
                if manager.fileExists(atPath: cacheURL!.path, isDirectory: &isDirectory), isDirectory.boolValue {
                    cacheURL = cacheURL!.appendingPathComponent(defaultCacheFileName)
                }
            }
        } else {
            setDefaultCacheURL()
        }

        func printRunningMessage() {
            print("Running SwiftFormat...", as: .info)
            if lint {
                print("(lint mode - no files will be changed.)", as: .info)
            } else if dryrun {
                print("(dryrun mode - no files will be changed.)", as: .info)
            }
        }

        enum Status {
            case idle, started, finished(ExitCode)
        }

        var input: String?
        var status = Status.idle
        func processFromStdin() {
            status = .started
            while let line = CLI.readLine() {
                input = (input ?? "") + line
            }
            guard let input else {
                status = .finished(.ok)
                return
            }
            do {
                var options = options
                if args["infer-options"] != nil {
                    let tokens = tokenize(input)
                    options.formatOptions = inferFormatOptions(from: tokens)
                    try serializeOptions(options, to: outputURL)
                    status = .finished(.ok)
                } else {
                    printRunningMessage()
                    if let stdinURL = options.formatOptions?.fileInfo.filePath.map(URL.init(fileURLWithPath:)) {
                        try gatherOptions(&options, for: stdinURL, with: { print($0, as: .info) })
                        if options.shouldSkipFile(stdinURL) {
                            print(input, as: .raw)
                            status = .finished(.ok)
                            return
                        }

                        let resourceValues = try getResourceValues(
                            for: stdinURL.standardizedFileURL,
                            keys: [.creationDateKey, .pathKey]
                        )

                        let fileInfo = collectFileInfo(inputURL: stdinURL,
                                                       options: options,
                                                       resourceValues: resourceValues)

                        options.formatOptions?.fileInfo = fileInfo
                        try options.addFilterArguments(path: stdinURL.path)
                    }
                    let outputTokens = try applyRules(
                        input, options: options, lineRange: lineRange,
                        verbose: verbose, lint: lint, reporter: reporter
                    )
                    let output = sourceCode(for: outputTokens)
                    if let outputURL, !useStdout {
                        if !dryrun, (try? String(contentsOf: outputURL)) != output {
                            try write(output, to: outputURL)
                        }
                    } else if !lint {
                        // Write to stdout
                        if printTokens {
                            let tokensToPrint = dryrun ? tokenize(input) : outputTokens
                            try print(OutputTokensData.encodedString(for: tokensToPrint), as: .raw)
                        } else {
                            print(dryrun ? input : output, as: .raw)
                        }
                    } else if let reporterOutput = try reporter.write() {
                        if let reportURL {
                            print("Writing report file to \(reportURL.path)", as: .info)
                            try reporterOutput.write(to: reportURL, options: .atomic)
                        } else {
                            print(String(decoding: reporterOutput, as: UTF8.self), as: .raw)
                        }
                    }
                    let exitCode: ExitCode
                    if lint, output != input {
                        print("Source input did not pass lint check.", as: lenient ? .warning : .error)
                        exitCode = lenient ? .ok : .lintFailure
                    } else if strict, output != input {
                        print("Source input was reformatted.", as: .error)
                        exitCode = .lintFailure
                    } else {
                        print("SwiftFormat completed successfully.", as: .success)
                        exitCode = .ok
                    }
                    status = .finished(exitCode)
                }
            } catch {
                if printWarnings([error]) {
                    status = .finished(.error)
                } else {
                    status = .finished(.ok)
                }
                // Ensure input isn't lost
                print(input, as: .raw)
            }
        }

        if useStdin {
            processFromStdin()
            if case let .finished(exitCode) = status {
                return exitCode
            }
            return .ok
        } else if inputURLs.isEmpty {
            // If no input file, try stdin
            DispatchQueue.global(qos: .userInitiated).async {
                processFromStdin()
            }
            // Wait for input
            while case .idle = status {}
            let start = NSDate()
            while input == nil, start.timeIntervalSinceNow > -0.2 {}
            // If no input received by now, assume none is coming
            if input != nil {
                while start.timeIntervalSinceNow > -30 {
                    if case let .finished(exitCode) = status {
                        return exitCode
                    }
                }
            } else if args["infer-options"] != nil {
                throw FormatError.options("--infer-options requires one or more input files")
            } else {
                printHelp(as: .info)
            }
            return .ok
        }

        printRunningMessage()

        // Format the code
        var outputFlags: OutputFlags = (0, 0, 0, 0)
        let time = formatTime(timeEvent {
            var _errors: [Error]
            (outputFlags, _errors) = processInput(inputURLs,
                                                  andWriteToOutput: outputURL,
                                                  options: options,
                                                  overrides: overrides,
                                                  lineRange: lineRange,
                                                  verbose: verbose,
                                                  dryrun: dryrun,
                                                  lint: lint,
                                                  lenient: lenient,
                                                  cacheURL: cacheURL,
                                                  reporter: reporter)
            errors += _errors
        })

        if printWarnings(errors) {
            return .error
        }
        if outputFlags.filesChecked == 0, outputFlags.filesSkipped == 0 {
            let inputPaths = inputURLs.map(\.path).formattedList(lastSeparator: "or")
            print("warning: No eligible files found at \(inputPaths).", as: .warning)
        }
        if let reporterOutput = try reporter.write() {
            if let reportURL {
                print("Writing report file to \(reportURL.path)", as: .info)
                try reporterOutput.write(to: reportURL, options: .atomic)
            } else {
                print(String(decoding: reporterOutput, as: UTF8.self), as: .raw)
            }
        }
        print("SwiftFormat completed in \(time).", as: .success)
        return printResult(dryrun, lint, lenient, strict, outputFlags)
    } catch {
        _ = printWarnings(errors)
        // Fatal error
        var message = "\(error)"
        if ![".", "?", "!"].contains(message.last ?? " ") {
            message += "."
        }
        print("error: \(message)", as: .error)
        return .error
    }
}

func write(_ output: String, to file: URL) throws {
    do {
        let fm = FileManager.default
        let attributes = try? fm.attributesOfItem(atPath: file.path)
        try output.write(to: file, atomically: true, encoding: .utf8)
        if let created = attributes?[.creationDate] {
            try? fm.setAttributes([.creationDate: created], ofItemAtPath: file.path)
        }
    } catch {
        throw FormatError.writing("Failed to write file \(file.path)")
    }
}

func parseFileList(_ source: String, in directory: String) throws -> [URL] {
    try source
        .components(separatedBy: .newlines)
        .map { $0.components(separatedBy: "#")[0].trimmingCharacters(in: .whitespaces) }
        .flatMap { try parsePaths($0, in: directory) }
}

func parseScriptInput(from environment: [String: String]) throws -> [URL] {
    guard let countString = environment["SCRIPT_INPUT_FILE_COUNT"],
          let count = Int(countString)
    else {
        throw FormatError
            .options("--script-input requires a configured SCRIPT_INPUT_FILE_COUNT integer variable")
    }

    return try (0 ..< count).map { index in
        guard let file = environment["SCRIPT_INPUT_FILE_\(index)"] else {
            throw FormatError
                .options("Input file count is \(count), but SCRIPT_INPUT_FILE_\(index) is not present")
        }
        return URL(fileURLWithPath: file)
    }
}

func printResult(_ dryrun: Bool, _ lint: Bool, _ lenient: Bool, _ strict: Bool, _ flags: OutputFlags) -> ExitCode {
    let (written, checked, skipped, failed) = flags
    let ignored = (skipped == 0) ? "" : ", \(skipped) file\(skipped == 1 ? "" : "s") skipped"
    if checked == 0 {
        print("0 files formatted\(ignored).", as: .info)
    } else if lint {
        if failed > 0 {
            print("Source input did not pass lint check.", as: .error)
        }
        print("\(failed)/\(checked) files require formatting\(ignored).", as: .info)
    } else if dryrun {
        print("\(failed)/\(checked) files would have been formatted\(ignored).", as: .info)
    } else {
        print("\(written)/\(checked) files formatted\(ignored).", as: .info)
    }
    return ((!lenient && lint) || strict) && failed > 0 ? .lintFailure : .ok
}

func inferOptions(from inputURLs: [URL], options: FileOptions) -> (Int, FormatOptions, [Error]) {
    var tokens = [Token]()
    var filesParsed = 0
    var options = options
    // Avoid trying to tokenize markdown files
    // TODO: need a more robust solution
    options.supportedFileExtensions.removeAll(where: { $0 == "md" })
    let baseOptions = Options(fileOptions: options)
    let errors = enumerateFiles(
        withInputURLs: inputURLs,
        options: baseOptions,
        logger: { print($0, as: .info) }
    ) { inputURL, _, _ in
        guard let input = try? String(contentsOf: inputURL) else {
            throw FormatError.reading("Failed to read file \(inputURL.path)")
        }
        let _tokens = tokenize(input)
        return {
            filesParsed += 1
            tokens += _tokens
        }
    }
    return (filesParsed, inferFormatOptions(from: tokens), errors)
}

func computeHash(_ source: String) -> String {
    var count = 0
    var hash: UInt64 = 5381
    for byte in source.utf8 {
        count += 1
        hash = 127 &* (hash & 0x00FF_FFFF_FFFF_FFFF) &+ UInt64(byte)
    }
    return "\(count)\(hash)"
}

func applyRules(_ source: String, tokens: [Token]? = nil, options: Options, lineRange: ClosedRange<Int>?,
                verbose: Bool, lint: Bool, reporter: Reporter?) throws -> [Token]
{
    // Parse source
    var tokens = tokens ?? tokenize(source)

    // Get rules
    let rulesByName = FormatRules.byName
    let ruleNames = Array(options.rules ?? defaultRules).sorted()
    let rules = ruleNames.compactMap { rulesByName[$0] }

    if verbose, let path = options.formatOptions?.fileInfo.filePath {
        print("\(lint ? "Linting" : "Formatting") \(path)", as: .info)
    }

    // Apply rules
    let formatOptions = options.formatOptions ?? .default
    var changes = [Formatter.Change]()
    let range = lineRange.map { tokenRange(forLineRange: $0, in: tokens) }
    (tokens, changes) = try applyRules(
        rules, to: tokens, with: formatOptions,
        trackChanges: lint || verbose || reporter != nil,
        range: range
    )

    // Display info
    let updatedSource = sourceCode(for: tokens)
    if lint, updatedSource != source {
        reporter?.report(changes)
    }
    if verbose {
        let rulesApplied = changes.reduce(into: Set<String>()) {
            $0.insert($1.rule.name)
        }
        if rulesApplied.isEmpty || updatedSource == source {
            print("-- no changes", as: .success)
        } else {
            let sortedNames = Array(rulesApplied).sorted().formattedList(lastSeparator: "and")
            print("-- rules applied: \(sortedNames)", as: .success)
        }
    }

    // Output
    return tokens
}

func processInput(_ inputURLs: [URL],
                  andWriteToOutput outputURL: URL?,
                  options: Options,
                  overrides: [String: String],
                  lineRange: ClosedRange<Int>?,
                  verbose: Bool,
                  dryrun: Bool,
                  lint: Bool,
                  lenient _: Bool,
                  cacheURL: URL?,
                  reporter: Reporter?) -> (OutputFlags, [Error])
{
    // Load cache
    let cacheDirectory = cacheURL?.deletingLastPathComponent().absoluteURL
    var cache: [String: String]?
    if let cacheURL {
        if let data = try? Data(contentsOf: cacheURL) {
            cache = try? JSONDecoder().decode([String: String].self, from: data)
        }
        cache = cache ?? [:]
    }
    // Logging skipped files
    var outputFlags: OutputFlags = (0, 0, 0, 0)
    let skippedHandler: FileEnumerationHandler? = verbose ? { inputURL, _, _ in
        print("Skipping \(inputURL.path)", as: .info)
        print("-- ignored", as: .warning)
        return {}
    } : { _, _, _ in
        { outputFlags.filesSkipped += 1 }
    }
    // Swift version
    var shownWarnings = Set<String>()
    var showedConfigurationWarnings = false
    func showConfigurationWarnings(_ options: Options) {
        let arguments = argumentsFor(options, excludingDefaults: true)
        let warnings = warningsForArguments(arguments, ignoreUnusedOptions: true)
        for warning in warnings where !shownWarnings.contains(warning) {
            shownWarnings.insert(warning)
            print("warning: \(warning)", as: .warning)
        }
        guard !showedConfigurationWarnings else {
            return
        }
        let formatOptions = options.formatOptions ?? .default
        if formatOptions.swiftVersion == .undefined {
            print("warning: No Swift version was specified, so some formatting features were disabled. Specify the version of Swift you are using with the --swift-version option, or by adding a \(swiftVersionFile) file to your project.", as: .warning)
        }
        if formatOptions.useTabs, formatOptions.tabWidth <= 0, !formatOptions.smartTabs {
            print("warning: The --smarttabs option is disabled, but no --tabwidth was specified.", as: .warning)
        }
        showedConfigurationWarnings = true
    }
    // Format files
    var errors = enumerateFiles(
        withInputURLs: inputURLs,
        outputURL: outputURL,
        options: options,
        concurrent: !verbose,
        logger: { print($0, as: .info) },
        skipped: skippedHandler
    ) { inputURL, outputURL, options in
        guard let input = try? String(contentsOf: inputURL) else {
            throw FormatError.reading("Failed to read file \(inputURL.path)")
        }
        // Override options
        var options = options
        try options.addArguments(overrides, in: "") // No need for directory as overrides are formatOptions only
        try options.addFilterArguments(path: inputURL.path)
        let formatOptions = options.formatOptions ?? .default
        let range = lineRange.map { "\($0.lowerBound),\($0.upperBound);" } ?? ""
        // Check cache
        let rules = options.rules ?? defaultRules
        let configHash = computeHash("\(formatOptions)\(range)\(rules.sorted().joined(separator: ","))")
        let cachePrefix = "\(version);\(configHash);"
        let cacheKey: String = {
            var path = inputURL.absoluteURL.path
            if let cacheDirectory {
                let commonPrefix = path.commonPrefix(with: cacheDirectory.path)
                path = String(path[commonPrefix.endIndex ..< path.endIndex])
            }
            return path
        }()
        do {
            var cacheHash: String?
            var sourceHash: String?
            if let cacheEntry = cache?[cacheKey], cacheEntry.hasPrefix(cachePrefix) {
                cacheHash = String(cacheEntry[cachePrefix.endIndex...])
                sourceHash = computeHash(input)
            }
            let output: String
            if let cacheHash, cacheHash == sourceHash {
                output = input
                if verbose {
                    print("\(lint ? "Linting" : "Formatting") \(inputURL.path)", as: .info)
                    print("-- no changes (cached)", as: .success)
                }
            } else if inputURL.pathExtension == "md" {
                var markdown = input
                let swiftCodeBlocks: [MarkdownCodeBlock]
                do {
                    swiftCodeBlocks = try parseCodeBlocks(fromMarkdown: input, language: "swift")
                } catch {
                    switch (options.formatOptions ?? .default).markdownFiles {
                    case .strict:
                        throw error
                    case .lenient, .ignore:
                        swiftCodeBlocks = []
                    }
                }

                // Iterate backwards through the code blocks to not invalidate existing indices
                for swiftCodeBlock in swiftCodeBlocks.reversed() {
                    // Determine the options to use when formatting this block
                    var options = options
                    if swiftCodeBlock.options?.contains("no-format") == true {
                        continue
                    } else if let args = swiftCodeBlock.options?.components(separatedBy: " "), !args.isEmpty {
                        let arguments = try preprocessArguments(args, commandLineArguments)
                        try applyArguments(arguments, lint: lint, to: &options)
                    }

                    // Set fragment mode
                    var formatOptions = options.formatOptions ?? .default
                    if formatOptions.markdownFiles == .ignore {
                        continue
                    }
                    formatOptions.fragment = true
                    options.formatOptions = formatOptions

                    // Update linebreak line numbers to reflect the actual line in the markdown file
                    // rather than only the line within the code block. This makes it easier to
                    // understand printed diagnostics that include line numbers.
                    let inputTokens = tokenize(swiftCodeBlock.text).map { token in
                        if case let .linebreak(string, lineInCodeBlock) = token {
                            return Token.linebreak(string, lineInCodeBlock + swiftCodeBlock.lineStartIndex)
                        } else {
                            return token
                        }
                    }

                    var outputTokens: [Token]?
                    let parsingError = parsingError(for: inputTokens, options: formatOptions,
                                                    allowErrorsInFragments: false)

                    switch formatOptions.markdownFiles {
                    case .lenient, .ignore:
                        // Ignore code blocks that fail to parse
                        if parsingError == nil {
                            outputTokens = try? applyRules(swiftCodeBlock.text, tokens: inputTokens,
                                                           options: options, lineRange: lineRange,
                                                           verbose: verbose, lint: lint, reporter: reporter)
                        }
                    case .strict:
                        if let parsingError {
                            throw parsingError
                        }

                        outputTokens = try applyRules(swiftCodeBlock.text, tokens: inputTokens,
                                                      options: options, lineRange: lineRange,
                                                      verbose: verbose, lint: lint, reporter: reporter)
                    }

                    if let outputTokens {
                        assert(markdown[swiftCodeBlock.range] == swiftCodeBlock.text)
                        markdown.replaceSubrange(swiftCodeBlock.range, with: sourceCode(for: outputTokens))
                    }
                }

                output = markdown
                if markdown != input {
                    sourceHash = nil
                }
            } else {
                // Regular swift file
                let outputTokens = try applyRules(input, options: options, lineRange: lineRange,
                                                  verbose: verbose, lint: lint, reporter: reporter)
                output = sourceCode(for: outputTokens)
                if output != input {
                    sourceHash = nil
                }
            }
            let cacheValue = cache.map { _ in
                // Only bother computing this if cache is enabled
                cachePrefix + (sourceHash ?? computeHash(output))
            }
            if outputURL.path.components(separatedBy: "/").contains("stdout") {
                if !dryrun {
                    // Write to stdout
                    print(output, as: .raw)
                    return {
                        outputFlags.filesChecked += 1
                        outputFlags.filesFailed += 1
                        outputFlags.filesWritten += 1
                        showConfigurationWarnings(options)
                    }
                }
            } else if outputURL != inputURL, (try? String(contentsOf: outputURL)) != output {
                if !dryrun {
                    do {
                        try FileManager.default.createDirectory(at: outputURL.deletingLastPathComponent(),
                                                                withIntermediateDirectories: true,
                                                                attributes: nil)
                    } catch {
                        throw FormatError.writing("Failed to create directory at \(outputURL.path), \(error)")
                    }
                }
            } else if output == input {
                // No changes needed
                return {
                    outputFlags.filesChecked += 1
                    cache?[cacheKey] = cacheValue
                    showConfigurationWarnings(options)
                }
            }
            if dryrun {
                return {
                    outputFlags.filesChecked += 1
                    outputFlags.filesFailed += 1
                    showConfigurationWarnings(options)
                }
            } else {
                if verbose {
                    print("Writing \(outputURL.path)", as: .info)
                }
                try write(output, to: outputURL)
                return {
                    outputFlags.filesChecked += 1
                    outputFlags.filesFailed += 1
                    outputFlags.filesWritten += 1
                    cache?[cacheKey] = cacheValue
                    showConfigurationWarnings(options)
                }
            }
        } catch {
            if verbose {
                var message = "\(error)"
                if !".?!".contains(message.last ?? " ") {
                    message += "."
                }
                print("-- error: \(message)", as: .error)
            }
            return {
                outputFlags.filesChecked += 1
                showConfigurationWarnings(options)
                switch error {
                case let FormatError.parsing(message):
                    if let range = message.range(of: ". Valid options") ?? message.range(of: ". Did you mean") {
                        throw FormatError.parsing("""
                        \(message[..<range.lowerBound]) in \(inputURL.path)\(message[range.lowerBound...])
                        """)
                    }
                    throw FormatError.parsing("\(message) in \(inputURL.path)")
                case let FormatError.writing(message):
                    throw FormatError.writing("\(message) in \(inputURL.path)")
                default:
                    throw error
                }
            }
        }
    }
    if verbose {
        var errorCount = errors.count
        errors = errors.filter { error in
            guard let error = error as? FormatError else {
                return true
            }
            switch error {
            case .options, .reading:
                return true
            case .parsing, .writing:
                return false
            }
        }
        errorCount -= errors.count
        if errorCount > 0 {
            // Replace individual warnings with a generic message, to avoid repetition
            errors.append(FormatError.writing("\(errorCount) file\(errorCount == 1 ? "" : "s") could not be formatted"))
        }
    }
    // Save cache
    if outputFlags.filesChecked > 0, let cache, let cacheURL, let cacheDirectory {
        do {
            let data = try JSONEncoder().encode(cache)
            try data.write(to: cacheURL, options: .atomic)
        } catch {
            if FileManager.default.fileExists(atPath: cacheDirectory.path) {
                errors.append(FormatError.writing("Failed to write cache file at \(cacheURL.path)"))
            } else {
                errors.append(FormatError.reading("Specified cache file directory does not exist: \(cacheDirectory.path)"))
            }
        }
    }
    return (outputFlags, errors)
}

/// The data format used with `--output-tokens`
private struct OutputTokensData: Encodable {
    init(tokens: [Token]) {
        self.tokens = tokens
        version = swiftFormatVersion
    }

    /// The SwiftFormat version that this data originated from
    let version: String
    /// A representation of the output in `Tokens`
    let tokens: [Token]

    /// Creates the `OutputTokensData` and encodes it to a JSON string
    static func encodedString(for tokens: [Token]) throws -> String {
        let outputData = OutputTokensData(tokens: tokens)

        let encoder = JSONEncoder()
        encoder.outputFormatting = .sortedKeys

        let encodedData = try encoder.encode(outputData)
        return String(data: encodedData, encoding: .utf8)!
    }
}

/// A code block from a markdown file
///
/// For example:
///
/// ```{language} {{options like `no-format`, `--disable ruleName` can be put here}}
/// // This content is returned as text,
/// // and its range in the markdown string is returned as range.
/// ```
struct MarkdownCodeBlock {
    let language: String
    let range: Range<String.Index>
    let text: String
    let options: String?
    let lineStartIndex: Int
}

/// Parses code blocks of a specific language in the given markdown file.
///
/// Any text following the open delimiter on that initial line is returned in `options`.
///
/// For example:
///
/// ```{language} {{options like `no-format`, `--disable ruleName` can be put here}}
/// // This content is returned as text,
/// // and its range in the markdown string is returned as range.
/// ```
func parseCodeBlocks(fromMarkdown markdown: String, language: String) throws -> [MarkdownCodeBlock] {
    struct PartialCodeBlock {
        let lineStartIndex: Int
        let topLevel: Bool
        let tickCount: Int
        let textAfterTicks: String
    }

    let lines = markdown.lineRanges
    var codeBlocks: [MarkdownCodeBlock] = []
    var codeBlockStack = [PartialCodeBlock]()

    for (lineIndex, lineRange) in lines.enumerated() {
        let lineText = markdown[lineRange].trimmingCharacters(in: .whitespacesAndNewlines)
        let ticks = String(lineText.prefix(while: { $0 == "`" }))
        let tickCount = ticks.count
        guard tickCount >= 3 else { continue }

        let textAfterTicks = String(lineText.dropFirst(tickCount))

        // If this fence has a different number of ticks from the previous fence,
        // or has text like ```language, then it's an open fence.
        let isOpenFence = !textAfterTicks.isEmpty
            || codeBlockStack.last?.tickCount != tickCount

        if isOpenFence {
            codeBlockStack.append(PartialCodeBlock(
                lineStartIndex: lineIndex + 1,
                topLevel: codeBlockStack.isEmpty,
                tickCount: tickCount,
                textAfterTicks: textAfterTicks
            ))
        }

        else if let partialBlock = codeBlockStack.popLast() {
            // Only store and return blocks that are top-level and match the given language
            guard partialBlock.topLevel,
                  partialBlock.textAfterTicks.hasPrefix(language),
                  lineIndex != partialBlock.lineStartIndex
            else { continue }

            let options = String(partialBlock.textAfterTicks.dropFirst(language.count))
                .trimmingCharacters(in: .whitespacesAndNewlines)

            let codeEnd = lines[lineIndex - 1].upperBound
            let range = lines[partialBlock.lineStartIndex].lowerBound ..< codeEnd
            let codeText = String(markdown[range])
            assert(markdown[range] == codeText)

            codeBlocks.append(MarkdownCodeBlock(
                language: language,
                range: range,
                text: codeText,
                options: options.isEmpty ? nil : options,
                lineStartIndex: partialBlock.lineStartIndex
            ))
        }
    }

    // Check for unbalanced code blocks
    if !codeBlockStack.isEmpty {
        throw FormatError.parsing("Unbalanced code block delimiters in markdown")
    }

    return codeBlocks
}
